<!-- 声明文档类型为 HTML5，确保浏览器以标准模式渲染 -->
<!DOCTYPE html> <!-- HTML5 文档类型声明 -->
<!-- HTML 文档根节点，设置语言为中文 -->
<html lang="zh-CN"> <!-- 设置页面语言为简体中文 -->
<!-- 文档头部，包含元信息与样式 -->
<head> <!-- 页面头部开始 -->
  <!-- 设置字符编码为 UTF-8，防止中文乱码 -->
  <meta charset="UTF-8"> <!-- 字符编码 -->
  <!-- 设置视口，确保在移动端良好显示 -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- 响应式视口设置 -->
  <!-- 页面标题 -->
  <title>OSS 分片上传示例</title> <!-- 浏览器标签标题 -->
  <!-- 简单的内联样式，用于基本排版与进度条展示 -->
  <style> /* 页面样式开始 */
    /* 设置页面基础字体与间距 */
    body { font-family: Arial, sans-serif; margin: 24px; } /* 页面主体样式 */
    /* 容器样式，限定宽度居中显示 */
    .container { max-width: 900px; margin: 0 auto; } /* 内容容器样式 */
    /* 表单控件的统一样式 */
    .row { margin-bottom: 12px; } /* 每行控件的间距 */
    label { display: inline-block; width: 140px; } /* 标签占位宽度 */
    input[type="number"], input[type="text"] { width: 160px; } /* 文本与数字输入框宽度 */
    /* 进度条外框样式 */
    .progress { width: 100%; height: 12px; background: #eee; border-radius: 6px; overflow: hidden; } /* 总进度条容器 */
    /* 进度条填充样式 */
    .progress-inner { height: 100%; width: 0%; background: #4caf50; transition: width 0.2s; } /* 总进度填充条 */
    /* 分片列表样式 */
    .parts { max-height: 240px; overflow-y: auto; border: 1px solid #ddd; padding: 8px; } /* 分片明细容器 */
    .part-item { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed #eee; } /* 分片明细项样式 */
    /* 按钮区域样式 */
    .actions button { margin-right: 12px; } /* 操作按钮间距 */
    /* 状态文本样式 */
    .status { margin-top: 10px; color: #333; } /* 状态信息样式 */
    /* 提示文本样式 */
    .tip { color: #888; font-size: 12px; } /* 说明文字样式 */
  </style> <!-- 页面样式结束 -->
</head> <!-- 页面头部结束 -->

<!-- 文档主体内容 -->
<body> <!-- 页面主体开始 -->
  <!-- 主容器，用于承载所有控件与显示区域 -->
  <div class="container"> <!-- 内容容器开始 -->
    <!-- 页面主标题 -->
    <h2>阿里云 OSS 分片上传（前端 HTML 示例）</h2> <!-- 主标题显示 -->

    <!-- 文件选择行 -->
    <div class="row"> <!-- 文件选择行开始 -->
      <label for="fileInput">选择文件：</label> <!-- 文件选择标签 -->
      <input id="fileInput" type="file" /> <!-- 文件选择输入控件 -->
    </div> <!-- 文件选择行结束 -->

    <!-- 分片大小设置行（以 MB 为单位） -->
    <div class="row"> <!-- 分片大小设置行开始 -->
      <label for="chunkSize">分片大小（MB）：</label> <!-- 分片大小标签 -->
      <input id="chunkSize" type="number" min="1" value="5" /> <!-- 分片大小输入框，默认 5MB -->
      <span class="tip">建议≥5MB，OSS 单片最大 5GB，分片数上限 10000</span> <!-- 说明文字 -->
    </div> <!-- 分片大小设置行结束 -->

    <!-- 并发数设置行 -->
    <div class="row"> <!-- 并发数设置行开始 -->
      <label for="concurrency">并发上传数：</label> <!-- 并发数标签 -->
      <input id="concurrency" type="number" min="1" max="10" value="3" /> <!-- 并发数输入框，默认 3 -->
      <span class="tip">控制同时上传的分片数量，建议 3~5</span> <!-- 说明文字 -->
    </div> <!-- 并发数设置行结束 -->

    <!-- 操作按钮区域 -->
    <div class="row actions"> <!-- 操作按钮区域开始 -->
      <button id="startBtn">开始上传</button> <!-- 开始上传按钮 -->
      <button id="abortBtn" disabled>取消上传</button> <!-- 取消上传按钮，初始禁用 -->
    </div> <!-- 操作按钮区域结束 -->

    <!-- 总体进度条显示 -->
    <div class="row"> <!-- 总进度条行开始 -->
      <label>整体进度：</label> <!-- 进度条标签 -->
      <div class="progress"> <!-- 进度条容器 -->
        <div id="overallProgress" class="progress-inner"></div> <!-- 进度条填充 -->
      </div> <!-- 进度条容器结束 -->
    </div> <!-- 总进度条行结束 -->

    <!-- 分片明细显示区域 -->
    <div class="row"> <!-- 分片明细区域开始 -->
      <label>分片进度：</label> <!-- 分片进度标签 -->
      <div id="partsList" class="parts"></div> <!-- 分片列表容器 -->
    </div> <!-- 分片明细区域结束 -->

    <!-- 状态文本显示 -->
    <div id="status" class="status">等待选择文件并点击开始上传</div> <!-- 状态信息显示容器 -->
  </div> <!-- 内容容器结束 -->

  <!-- 页面脚本，包含分片切分、并发上传、进度展示与接口调用 -->
  <script> // 脚本开始
    // 后端接口基础路径（确保与后端控制器 @RequestMapping 一致）
    const API_BASE = '/api/multipart'; // 后端接口前缀

    // 统一判断后端成功码（兼容后端返回的 code 为 0 或 200 的两种情况）
    function isSuccess(json) { // 成功码判断工具函数
      try { // 保护性判断，避免 json 为空导致异常
        const code = json && json.code; // 读取响应中的业务码
        return code === 0 || code === 200; // 兼容 0 与 200 视为成功
      } catch (e) { // 异常兜底
        return false; // 异常时视为失败
      }
    } // 成功码判断函数结束

    // 选择器获取页面元素引用
    const fileInput = document.getElementById('fileInput'); // 文件选择输入框引用
    const chunkSizeInput = document.getElementById('chunkSize'); // 分片大小输入框引用
    const concurrencyInput = document.getElementById('concurrency'); // 并发数输入框引用
    const startBtn = document.getElementById('startBtn'); // 开始上传按钮引用
    const abortBtn = document.getElementById('abortBtn'); // 取消上传按钮引用
    const partsList = document.getElementById('partsList'); // 分片列表容器引用
    const overallProgress = document.getElementById('overallProgress'); // 总进度条填充元素引用
    const statusEl = document.getElementById('status'); // 状态显示元素引用

    // 运行时状态变量，用于管理上传流程
    let currentUploadId = null; // 当前上传会话 ID
    let currentObjectName = null; // 当前对象名（路径）
    let aborted = false; // 是否已触发取消上传标志
    let partTasks = []; // 分片任务列表（包含分片号与 Blob 数据）
    let partResults = []; // 分片上传结果列表（包含分片号与 ETag）

    // 绑定开始上传按钮事件
    startBtn.addEventListener('click', async () => { // 点击开始上传事件处理
      // 重置状态并读取用户输入
      aborted = false; // 重置取消标志
      partsList.innerHTML = ''; // 清空分片列表显示
      partTasks = []; // 清空分片任务列表
      partResults = []; // 清空分片结果列表
      overallProgress.style.width = '0%'; // 重置总进度条宽度

      // 校验选择的文件
      const file = fileInput.files[0]; // 获取用户选择的第一个文件
      if (!file) { // 若未选择文件
        setStatus('请先选择文件'); // 更新状态提示
        return; // 中止处理
      } // 文件校验结束

      // 读取分片大小与并发数设置
      const chunkSizeMB = Math.max(1, parseInt(chunkSizeInput.value || '5', 10)); // 分片大小（单位 MB），下限 1MB
      const concurrency = Math.max(1, Math.min(10, parseInt(concurrencyInput.value || '3', 10))); // 并发数（1~10）
      const chunkSize = chunkSizeMB * 1024 * 1024; // 分片大小字节数

      // 调用后端初始化接口，获取 uploadId 与 objectName
      setStatus('初始化分片上传...'); // 更新状态提示
      const initResp = await fetch(`${API_BASE}/initiate`, { // 发起初始化请求
        method: 'POST', // 使用 POST 方法
        headers: { 'Content-Type': 'application/json' }, // 设置请求体类型为 JSON
        body: JSON.stringify({ filename: file.name, contentType: file.type || 'application/octet-stream' }) // 传递文件名与内容类型
      }); // fetch 结束
      const initJson = await initResp.json(); // 解析 JSON 响应
      if (!isSuccess(initJson)) { // 若初始化失败（兼容后端 code=0 与 code=200 为成功）
        setStatus('初始化失败：' + (initJson.message || '未知错误')); // 显示错误信息
        return; // 中止流程
      } // 初始化响应检查结束
      currentUploadId = initJson.data.uploadId; // 保存上传会话 ID
      currentObjectName = initJson.data.objectName; // 保存对象名

      // 根据分片大小切分文件，生成任务列表
      setStatus('开始切分文件...'); // 更新状态提示
      const totalParts = Math.ceil(file.size / chunkSize); // 计算分片总数
      for (let i = 0; i < totalParts; i++) { // 遍历生成每个分片任务
        const start = i * chunkSize; // 当前分片起始字节
        const end = Math.min(file.size, start + chunkSize); // 当前分片结束字节
        const blob = file.slice(start, end); // 使用 slice 获取分片 Blob
        partTasks.push({ partNumber: i + 1, blob }); // 压入分片任务（分片号从 1 开始）
        addPartRow(i + 1, end - start); // 在界面上增加分片行显示
      } // 分片任务生成结束

      // 启用取消按钮，允许用户主动取消
      abortBtn.disabled = false; // 允许点击取消
      setStatus('开始上传分片...'); // 更新状态提示

      // 并发执行分片上传
      await runConcurrentUploads(concurrency, async (task) => { // 开始并发上传，逐任务处理
        // 若已取消，则跳过后续任务
        if (aborted) return; // 取消后不再上传
        // 调用上传分片接口并更新进度
        const res = await uploadSinglePart(currentUploadId, currentObjectName, task.partNumber, task.blob); // 上传单个分片
        if (res) { // 若上传成功
          partResults.push({ partNumber: task.partNumber, eTag: res.eTag }); // 记录成功结果
          updatePartRow(task.partNumber, true, res.eTag); // 更新分片行显示为成功
          updateOverallProgress(partResults.length, totalParts); // 更新总体进度
        } else { // 上传失败
          updatePartRow(task.partNumber, false, ''); // 更新分片行显示为失败
        } // 成功/失败处理结束
      }); // 并发上传结束

      // 若用户取消，则调用终止接口并结束流程
      if (aborted) { // 如果已取消
        setStatus('已取消上传，开始请求后端清理...'); // 更新状态提示
        await abortUpload(currentUploadId, currentObjectName); // 调用后端终止接口
        abortBtn.disabled = true; // 禁用取消按钮
        return; // 结束流程
      } // 取消处理结束

      // 上传全部完成后，调用后端完成合并接口
      setStatus('全部分片已上传，开始请求后端合并...'); // 更新状态提示
      const completeResp = await fetch(`${API_BASE}/complete`, { // 发起合并请求
        method: 'POST', // 使用 POST 方法
        headers: { 'Content-Type': 'application/json' }, // 设置请求体类型为 JSON
        body: JSON.stringify({ uploadId: currentUploadId, objectName: currentObjectName, parts: partResults }) // 传递所有分片号与 ETag
      }); // fetch 结束
      const completeJson = await completeResp.json(); // 解析 JSON 响应
      if (!isSuccess(completeJson)) { // 若合并失败（兼容成功码判断）
        setStatus('合并失败：' + (completeJson.message || '未知错误')); // 显示错误信息
        return; // 中止流程
      } // 合并响应检查结束
      setStatus('上传完成！对象名：' + completeJson.data.objectName + '，最终ETag：' + completeJson.data.finalETag); // 显示成功信息
      abortBtn.disabled = true; // 禁用取消按钮
    }); // 开始上传按钮事件绑定结束

    // 绑定取消上传按钮事件
    abortBtn.addEventListener('click', async () => { // 点击取消上传事件处理
      aborted = true; // 设置取消标志
      setStatus('用户取消上传，等待当前请求结束...'); // 更新状态提示
      abortBtn.disabled = true; // 禁用取消按钮避免重复点击
      // 若已有会话信息则调用后端清理接口
      if (currentUploadId && currentObjectName) { // 校验会话信息存在
        await abortUpload(currentUploadId, currentObjectName); // 调用终止接口
      } // 终止调用结束
    }); // 取消按钮事件绑定结束

    // 上传单个分片，使用 XMLHttpRequest 以便监听上传进度
    async function uploadSinglePart(uploadId, objectName, partNumber, blob) { // 上传单片函数定义
      return new Promise((resolve) => { // 返回一个 Promise 以异步处理
        const formData = new FormData(); // 创建 multipart/form-data 请求体
        formData.append('uploadId', uploadId); // 放入会话 ID
        formData.append('objectName', objectName); // 放入对象名
        formData.append('partNumber', String(partNumber)); // 放入分片号
        formData.append('file', blob, `part-${partNumber}`); // 放入分片二进制数据

        const xhr = new XMLHttpRequest(); // 创建原生 XHR 对象
        xhr.open('POST', `${API_BASE}/upload`, true); // 配置为 POST 请求分片上传接口
        xhr.upload.onprogress = (event) => { // 绑定上传进度事件
          if (event.lengthComputable) { // 若总大小可计算
            const percent = Math.round((event.loaded / event.total) * 100); // 计算当前分片进度百分比
            updatePartProgress(partNumber, percent); // 更新界面分片进度
          } // 进度可计算判断结束
        }; // 上传进度事件绑定结束
        xhr.onreadystatechange = () => { // 绑定就绪状态变化事件
          if (xhr.readyState === 4) { // 当请求完成
            try { // 尝试解析响应
              const resp = JSON.parse(xhr.responseText); // 解析 JSON 响应
              if (xhr.status === 200 && isSuccess(resp)) { // HTTP 200 且业务成功（兼容成功码判断）
                resolve({ eTag: resp.data.eTag }); // 解析成功返回 ETag
              } else { // 业务或 HTTP 失败
                resolve(null); // 返回空表示失败
              } // 成功/失败判断结束
            } catch (e) { // 捕获解析异常
              resolve(null); // 返回空表示失败
            } // try-catch 结束
          } // 就绪状态判断结束
        }; // 就绪状态事件绑定结束
        xhr.send(formData); // 发送请求
      }); // Promise 结束
    } // 上传单片函数结束

    // 并发执行分片上传，限制同时运行的任务数
    async function runConcurrentUploads(limit, worker) { // 并发控制函数定义
      const queue = [...partTasks]; // 复制任务列表作为队列
      const workers = []; // 存放并发执行的 Promise
      for (let i = 0; i < limit; i++) { // 根据并发上限启动多个执行器
        workers.push((async () => { // 每个执行器是一个自执行的异步函数
          while (queue.length > 0 && !aborted) { // 队列非空且未取消
            const task = queue.shift(); // 取出队头任务
            await worker(task); // 执行上传工作
          } // 循环结束
        })()); // 启动执行器
      } // 并发执行器创建结束
      await Promise.all(workers); // 等待所有执行器完成
    } // 并发控制函数结束

    // 调用后端终止接口，清理已上传的分片
    async function abortUpload(uploadId, objectName) { // 终止上传函数定义
      try { // 捕获可能的网络异常
        const resp = await fetch(`${API_BASE}/abort`, { // 发起终止请求
          method: 'POST', // 使用 POST 方法
          headers: { 'Content-Type': 'application/json' }, // 设置请求体类型为 JSON
          body: JSON.stringify({ uploadId, objectName }) // 传递会话 ID 与对象名
        }); // fetch 结束
        const json = await resp.json(); // 解析 JSON 响应
        if (isSuccess(json)) { // 若业务成功（兼容成功码判断）
          setStatus('后端清理成功，上传会话已终止'); // 更新状态提示
        } else { // 若业务失败
          setStatus('后端清理失败：' + (json.message || '未知错误')); // 显示错误信息
        } // 成功/失败判断结束
      } catch (e) { // 捕获到异常
        setStatus('请求后端清理异常：' + e.message); // 显示异常信息
      } // try-catch 结束
    } // 终止上传函数结束

    // 在界面上新增一个分片行，用于显示分片状态与进度
    function addPartRow(partNumber, size) { // 新增分片显示行函数定义
      const row = document.createElement('div'); // 创建行元素
      row.className = 'part-item'; // 设置样式类名
      row.id = `part-${partNumber}`; // 设置元素 ID 以便后续更新
      row.innerHTML = `#${partNumber} 大小：${(size/1024/1024).toFixed(2)}MB <span class="tip">等待上传...</span>`; // 设置初始显示内容
      partsList.appendChild(row); // 添加到分片列表容器
    } // 新增分片显示行函数结束

    // 更新某个分片的上传进度百分比（仅展示数值）
    function updatePartProgress(partNumber, percent) { // 更新分片进度函数定义
      const row = document.getElementById(`part-${partNumber}`); // 获取分片行元素
      if (row) { // 若元素存在
        const text = row.innerText.split(' ')[0]; // 保留前半部分描述
        row.innerHTML = `${text} <span class="tip">上传进度：${percent}%</span>`; // 更新进度显示
      } // 元素存在判断结束
    } // 更新分片进度函数结束

    // 更新某个分片的上传结果状态（成功/失败），成功时显示 ETag
    function updatePartRow(partNumber, success, eTag) { // 更新分片结果函数定义
      const row = document.getElementById(`part-${partNumber}`); // 获取分片行元素
      if (!row) return; // 若元素不存在则直接返回
      const base = row.innerText.split(' ')[0]; // 基本描述部分
      if (success) { // 上传成功
        row.innerHTML = `${base} <span style="color:#4caf50">成功</span> <span class="tip">ETag: ${eTag}</span>`; // 显示成功与 ETag
      } else { // 上传失败
        row.innerHTML = `${base} <span style="color:#f44336">失败</span>`; // 显示失败
      } // 成功/失败判断结束
    } // 更新分片结果函数结束

    // 更新整体进度条，根据已上传分片数计算百分比
    function updateOverallProgress(done, total) { // 更新整体进度函数定义
      const percent = Math.round((done / total) * 100); // 计算百分比
      overallProgress.style.width = `${percent}%`; // 设置进度条宽度
    } // 更新整体进度函数结束

    // 更新页面状态提示文本
    function setStatus(text) { // 设置状态文本函数定义
      statusEl.textContent = text; // 修改状态元素的文本内容
    } // 设置状态文本函数结束
  </script> <!-- 脚本结束 -->
</body> <!-- 页面主体结束 -->
</html> <!-- 页面根节点结束 -->